iT邦幫忙

2026 iThome 鐵人賽

DAY 22
1
AI Engineering

不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天系列 第 22

🕵 Day 22 - 答錯不是模型笨:RAG 準確度的七層歸因

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260902/20183550k4A7x5INC9.png
昨天那張 12 GiB 預算表替 LLM、embedding、reranker 三張嘴分好了帳,第三張地圖收工。三週下來的帳很好看:Day 01 那個 45 秒的問題,關掉 thinking、換一顆塞得進顯存的量化檔剩 5 秒,再修四刀,現在不到 2 秒。

然後你會遇到一件比 45 秒更尷尬的事:它用不到兩秒,非常有自信地答錯,而且錯得很體面——有分段、有語氣,有時候還附頁碼。使用者不會說「你的檢索沒撈到」,只會說一句「這個怎麼是錯的」,然後不再打開它。

Uber 公開過內部 on-call copilot Genie 的線上數字:70,000 個以上的問題、154 個 Slack channel,helpfulness 48.9%【廠商自述,uber.com/blog/genie-ubers-gen-ai-on-call-copilot/,2024-10-10】。

這個數不是檢索指標,是使用者在 Slack 上自選回報按出來的,不能拿去跟你自己那份驗證題庫(golden set)算出來的 recall 比(分母為什麼可疑,D28 會回來拆)。

它只有一個用途:告訴你真實流量的體感離 demo 有多遠。一線大廠、專人維運,還不到一半。所以你的 RAG 讓你不滿意,不代表你做得特別差,但也不代表沒得修。

今天開第四張地圖。第一格不是任何一個參數,是一句話:「RAG 答錯」不是一個 bug,是一個複合事件。

慢有 profiler,錯沒有

「慢」有現成的尺:Day 02 把它拆成 TTFT、TPOT、E2E,每一刀砍下去都量得出省了幾毫秒。「不準」沒有——你看到一個錯答案,中間隔著七道工序,每一道都可能是兇手,症狀卻長得一模一樣。

七層的修法互不替代:檢索沒撈到,換再強的生成模型也沒用;生成不忠實,候選池開再大也沒用。不知道病在哪一層時,任何調參都是擲骰子——而骰子偶爾真的會中,那才最糟。 今天要做的:把「不準」這個字禁用掉,換成七個可以分開量的格子。

七層歸因:一次答錯,兇手躲在哪一格

https://ithelp.ithome.com.tw/upload/images/20260902/20183550WiSmGiGT9b.png
圖 1:使用者的問題只碰得到下面那一帶——上面那三層在建索引端留下的錯,在他按下 Enter 之前就已經定案了。

典型症狀 最小驗證方式 修法方向
L1 Parsing 答案明明在文件裡卻怎麼都撈不到;表格數字全錯 先算空白或極短 chunk 的比例,再隨機抽 5 個 chunk 看原文 換 parser、補 OCR、表格自成 chunk
L2 Chunking 答案被切成兩半;或命中的 chunk 很大、只有一句相關 gold_quote 去 grep 全部 chunk,看它是否落在單一 chunk small-to-big:檢索小 child、生成餵大 parent
L3 Embedding 查型號、料號、錯誤碼特別爛;版本錯的反而排很前 同一組專有名詞 query,dense 單跑、BM25 單跑,比 recall@50 開 hybrid(中文先驗斷詞)
L4 Retrieval 換 reranker、換 prompt、換模型都毫無反應 量 recall@100:答案在不在候選池裡 加大候選池、hybrid、用 metadata 限定搜尋範圍(分域)
L5 Ranking recall@50 很漂亮,recall@5 很難看 比較 recall@5 與 recall@50 的落差 cross-encoder reranker
L6 Assembly 檢索對了但抓錯段,context 一長特別明顯 正確 chunk 放在 context 頭、中、尾各跑一次 去重、最相關的放頭尾、每段前面標上來源與版本
L7 Generation context 裡明明有答案,模型還是自己編 逐句檢查每一句說法能不能被 context 支撐 要求答案附上來源、「找不到就說不知道」、換模型

L1 先跑健檢,再用眼睛看。 一份 80MB、七十幾頁的 PDF 每頁超過 1 MB【推算,80 MB ÷ 75 頁 ≈ 1.07 MB/頁】——純文字 PDF 一頁通常幾十到幾百 KB。1 MB 一頁在暗示掃描件、大量圖片、表格(Day 27 分開處理)或複雜向量圖,那幾種都會讓純文字抽取拿到空字串。

檔案大小只能起疑不能定罪,先抽頁看 parser 實際抽出了什麼:空白或極短 chunk 超過一兩成就先修【經驗閾值,非通用標準】。這時候「chunk 數會影響準度」是真的,但那是噪音不是訊號

L1–L3 在文件端改一項就要重建索引,L4–L7 多數修改重跑一次查詢就看得到。 兩邊各有例外:查詢端的 instruction 前綴改了不必重建,而 L4 的 metadata schema 與索引參數反而要。這也決定了診斷順序:先用不必重建索引的實驗定位。

L4 和 L5 長得像,但差一個字。 reranker 是排序的藥,不是候選池的藥——它能把正確答案從第 30 名拉到第 3 名、讓最終的 recall@5 變好看,但沒撈進候選池的東西它一個也變不出來。

兩個最便宜的診斷:oracle test 與 recall 階梯

實驗 A:標準答案測試(oracle test)——先問「只放正確 chunk,答不答得出來」。 把 golden set 裡的正確 chunk 直接手動貼進 prompt,檢索整段跳過不跑。

  • oracle 測試時仍常答錯 → 先把餵進去的 context 印出來對一次 gold_quote:那段文字裡真的有答案嗎?沒有就是 L1/L2 或標註的問題;有卻還是錯,範圍才縮到 L6/L7
  • oracle 下幾乎全對、線上很差 → 病在 L1–L6,外加「多了干擾段才壞的 L7」——這個 oracle 兩者都沒測。

第二條常被寫成「病在 L1–L5」。這個測試跳過的不只是檢索:排序、去重、截斷、來源標註樣板都沒有,所有干擾段也被拿掉了——正式環境的 L6 不在它的受測範圍內

想再往下分:把正確 chunk 放進正式候選池、照原本的排序與組裝走完,記錄一次它有沒有進到最終 prompt。沒進去是 L5 或截斷;進去了還錯仍然是 L6 或 L7。

它還附贈一條參考基準:Anthropic 建議知識庫小於 200,000 tokens(約 500 頁)就整包塞進 prompt、開 prompt caching,不必做 RAG
【引用,Anthropic, "Introducing Contextual Retrieval" 2024-09】。
那是工程建議、不是被證明過的門檻,而且只有同一顆模型、同一組 prompt 兩邊各跑一次,差距才近似檢索與組裝造成的損失。

也別當成硬上界,而且你多半跑不起來:正確段落放在頭、中、尾,正確率本來就不一樣;Qwen3-8B 官方只到 32,768 native、YaRN 驗證到 131,072
【官方,huggingface.co/Qwen/Qwen3-8B 】,200K 的 KV 要 27.5 GiB、開 FP8 也要 13.7【推算,200,000 × 144 KiB ≈ 27.47 GiB;FP8 減半 ≈ 13.73】,D21 那張 12 GiB 的卡裝不下。機器與模型都換掉之後,差距就混進模型本身的能力了。

實驗 B:recall 階梯——分開 L4 與 L5。 同一組 query,量 recall@5 / @10 / @20 / @50 / @100,看曲線的形狀。量的是第一階檢索器:跑之前先把 reranker 從 my_retriever 拿掉,不然量到的是整條管線的結果,看不出第一階檢索到底有沒有把答案撈進候選池。

形狀 判讀 修法
recall@5 低、recall@50 高(示意:0.45 → 0.92) 排序問題(L5),找得到只是排不前面 reranker,通常立刻見效
recall@5 低、recall@100 也低(示意:0.40 → 0.48) recall miss(L4),答案不在候選池 候選池裡沒有,reranker 也救不了;要修的是第一階檢索——hybrid、限定範圍,或上游的 L1 / L2
recall@5 就很高、線上仍答錯 L6,或有干擾才失敗的 L7 順序、去重、截斷、缺來源標註

括號裡那兩組是形狀示意,不是實測——你要認的是「兩端差很多」還是「兩端一樣爛」,絕對值請自己量。

https://ithelp.ithome.com.tw/upload/images/20260902/20183550R38k6atoRU.png
圖 2:走完兩個分叉,你拿到的是「哪一層」,不是「哪個參數」。

兩個實驗合起來大概半天。recall 那半段不需生成 token(查詢向量化與向量搜尋還是要跑),便宜到可以掛進 CI。

oracle 那半段,30–50 題先用人眼判最可靠:同一個意思,模型可能寫「250C」也可能寫「攝氏兩百五十度」,直接拿 exact match 判分會把答對的判成答錯。

為什麼「先調 chunk size」幾乎一定是錯的

答錯之後,多數人的第一個動作是打開設定檔改 chunk size。三個理由:

https://ithelp.ithome.com.tw/upload/images/20260902/20183550JrH1guvF95.png
圖 3:兩頭都有風險,所以往哪個方向轉都解釋得通。

一、它同時牽動三層。 三條線各有兩頭的風險,方向還不一致——同時在三層動手,分數變好變壞都記不到誰頭上。

二、它是文件內的旋鈕,救不了跨文件的病。 文件累積到上千份之後準度崩掉,是搜尋空間變大造成的稀釋,單靠 chunk size 治不好:它可能讓分數移動,卻不會讓資料量較少的類別在全域索引裡變顯眼。這是 Day 25 的主題。

三、上游很可能根本是壞的,見上一節。

所以本週的作業順序固定:先驗 L1 → 再跑 oracle 與 recall 階梯 → 最後才輪到旋鈕。 至於旋鈕有多不直覺,Day 24 會用公開實測證明:很多團隊從第一天就在用的那組預設值,在 token 級指標上接近最差【引用,Chroma, "Evaluating Chunking Strategies for Retrieval", 2024-07,trychroma.com/research/evaluating-chunking 】。

還有一句先立在這裡,它會貫穿 D25 到 D28:檢索指標變好,不代表答案更可信。 一份 2026 年的預印本裡,用 metadata 限定範圍讓 P@10 從 0.77 升到 0.86,但疊上多代理流程之後,faithfulness 反而從 0.61 掉到 0.35【引用,預印本 arXiv:2606.11350,2026-06,Table 3】。

recall 大幅下降也是多代理造成的,不是限定範圍本身。不過同一篇的其他表格出現相反方向,所以能借的是這個問題意識,不是它的絕對值。

第四張地圖:這週怎麼走

  • D23 embedding 選型與授權地雷(L3):排行榜第一名通常不是你的第一名。
  • D24 chunk 切幾刀(L2):把那顆旋鈕正式轉一輪,中文的部分我自己補。
  • D25 文件一多就完蛋(L3 / L4):稀釋的機制、hybrid,以及中文 BM25 的坑。
  • D26 routing 的四個階梯(L4 上游):計價單位是額外的呼叫數。
  • D27 / D28 企業現場:資料不只有 PDF,還有 DB、API 與 legacy。

你量出來的診斷卡,決定這週該讀哪幾天。

今天的實驗需要什麼

不需要新硬體,但需要一份 golden set,30 到 50 條起步,一天內建得完。唯一不能省的是每條都要標出答案在哪——這是把 L4 / L5 和 L7 分開量的唯一方法,也是最多人省略、然後永遠診斷不出病灶的一步。

但標成什麼,決定了它能活多久。腳本比對的是 gold_doc_ids,而那串 id 綁在當下這一版切法上:Day 24 一動 chunk size 就整份作廢。所以同時把穩定的證據存下來——文件、頁碼、原文那一句;換過切法重新映射就好,題目不必重寫。

# day22_triage.py --exp b  # recall 階梯: 純 ID 比對, 不碰生成模型
#                  --exp a  # 標準答案測試: 跳過檢索, 只需要生成模型
# golden.jsonl 每行: {"question", "gold_doc_ids", "gold_quote", "gold_answer"}
# my_embed / load_chunk / ask_llm 這三支是你自己的
import argparse, json

def my_retriever(docs, question, k):      # <- 你自己的檢索接在這裡, 回傳已排序的 doc_id
    # 自己算向量再送進去: near_text 要 collection 掛了 vectorizer 才能用,
    # 而本系列的 embedding 一路是自己 serve 的
    r = docs.query.near_vector(near_vector=my_embed(question), limit=k)
    return [o.properties["doc_id"] for o in r.objects]

def recall_at_k(ret, gold, k):
    return len(set(ret[:k]) & set(gold)) / max(1, len(set(gold)))

def exp_b(golden, retrieve, ks=(5, 10, 20, 50, 100)):
    """實驗 B: 看階梯的形狀"""
    kmax = max(ks)
    # 只檢索一次取 kmax 再切片; 代價是 HNSW 的 ef 跟著 limit 走,
    # 切片的小 k 會比真的 limit=5 樂觀, 階梯偏平
    hits = [(retrieve(g["question"], kmax), g["gold_doc_ids"]) for g in golden]
    for k in ks:
        r = sum(recall_at_k(ret, gold, k) for ret, gold in hits) / len(hits)
        print(f"recall@{k:<3} = {r:.3f}")

def exp_a(golden):
    """實驗 A: 跳過檢索, 直接餵正確 chunk; 對錯自己判"""
    for g in golden:
        ctx = "\n\n".join(load_chunk(i) for i in g["gold_doc_ids"])
        quote = g.get("gold_quote", "")
        # 餵進去的東西裡真的有答案嗎? 沒有就不是模型的錯, 是 L1/L2 或標註的錯
        in_ctx = (quote in ctx) if quote else None
        got = ask_llm(context=ctx, question=g["question"])
        print(g["question"], "| quote_in_ctx:", in_ctx,
              "| gold:", g.get("gold_answer", "(未標)"), "| got:", got)
        if quote and not in_ctx:
            print("   ctx =", repr(ctx))

ap = argparse.ArgumentParser()
ap.add_argument("--exp", choices=["a", "b"], default="b")
golden = [json.loads(line) for line in open("golden.jsonl", encoding="utf-8")]

if ap.parse_args().exp == "a":
    exp_a(golden)                                 # 完全不碰向量庫, 也不用裝 weaviate
else:
    import weaviate                               # 只有實驗 B 需要; D25 沿用這條連線
    with weaviate.connect_to_local() as client:
        docs = client.collections.get("Docs")
        exp_b(golden, lambda q, k: my_retriever(docs, q, k))
# 1) 準備 golden set: 每行一題, gold_doc_ids 是必填欄位
cat > golden.jsonl <<'EOF'
{"question":"熱處理爐 A 型的溫度上限是多少?","gold_doc_ids":["spec_v2#p12"],"gold_quote":"最高使用溫度為 250C","gold_answer":"250C"}
{"question":"PROD-SKU-7842X 的驗收標準?","gold_doc_ids":["qa_manual#p3"],"gold_quote":"250C 恆溫保持 30 分鐘後空冷","gold_answer":"250C 保持 30 分鐘"}
EOF

# 2) 實驗 B: recall 階梯(純 ID 比對, 不碰 LLM, 幾秒鐘跑完)
#    前提是向量庫與 embedding 服務都起來了 -- 本系列到 D25 才正式裝庫,
#    想今天就跑, 把 my_retriever 換成你現有的檢索即可
python day22_triage.py --exp b

# 3) 實驗 A: oracle test 才需要生成模型; 沿用 D21 預算表那顆
#    vllm serve 會佔住這個 terminal, 另開一個跑下面那行
CUDA_VISIBLE_DEVICES=0 vllm serve Qwen/Qwen3-8B-AWQ \
  --gpu-memory-utilization 0.60 --max-model-len 8192 --kv-cache-dtype fp8 --port 8000

python day22_triage.py --exp a

30–50 題足夠先找方向。要把單一 slice 的 80% 通過率估到 95% 信賴水準、± 5 個百分點,才需要約 246 筆【n = 1.96² × 0.8 × 0.2 ÷ 0.05² ≈ 245.9】;要比較兩版相差 2%、3% 算不算顯著,則要另做配對檢定,例如 McNemar。

這一篇不附我的階梯數字:輸出由你的語料與 golden set 決定,要帶走的是那張形狀判讀表。

小結

  • 答錯是複合事件:七層裡任一層壞掉,症狀都一樣,修法卻互不替代。
  • 兩個最便宜的診斷吃同一份 golden set:oracle test 先問「乾淨的證據餵進去答不答得出來」,recall 階梯分開 L4 與 L5。30–50 條起步,標註要能撐過 Day 24 的重新切分。
  • 先驗 L1,最後才輪到 chunk size:reranker 是排序的藥、救不了候選池;chunk size 同時牽動三層,上游壞掉時調的全是噪音。

明天 Day 23〈MTEB 第一名不是你的第一名:embedding 選型與授權地雷〉——同一張榜單上前幾名可能統計上根本沒差別,而分數最高的那顆,說不定連商用都不能用。

咱們明天見。


上一篇
Day 21 - 一張 12GB 卡怎麼替整套 RAG 編預算:LLM、embedding、reranker 的顯存分帳
下一篇
Day 23 - MTEB 第一名不是你的第一名:embedding 選型與授權地雷
系列文
不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言